Phase 7 — Wiki UX

Arc started 2026-07-22, after Phase 6. Sections are Roman numerals and steps within them are lettered; each step's title says what it does, followed by a sentence on what was proposed and, once worked, a few on what was done and anything learned. Marker legend: being worked on, implemented and waiting to be reviewed, done, closed by deciding not to do it, ? open decision, unmarked = planned.

What this arc is for

The wiki is where a group keeps its shared pages, so its editing and resource surfaces are what a group touches most. This arc finishes the page-resource folder UI (I), gives the editor a live preview and then a markup toolbar (II), makes the category mechanism a real content tool (III), completes the email witness flow for secret ballots (IV), and documents the folder UI once it is finished (V).

I. Finish the page-resource folder UI

To-do item 17. Most of it landed in Phase 2.5; two pieces stayed open.

  1. A. Multi-select with keyboard shortcuts. Pick rows with click, Shift-click and Control-click, then act on the picked set with Delete, F2, Control C/X/V and Escape.

    Rows are picked in the browser and nothing is sent until an action is asked for, when the whole set goes as one request. That needed the server widened: SocialComponent now answers delete_resources, move_resources, clip_copy_resources and clip_cut_resources, each taking a list. Dragging carries the picked set to either drop target, dimming the rows it carries and showing a count.

  2. B. CSS-Grid auto-fill rewrite of the folder layout. Replace the fixed-in-width table with repeat(auto-fill, minmax(min, 1fr)) and a container query so the folder reflows to the width it is given.

    table-layout: fixed and four hard column widths gave the list a 502px floor that overflowed a phone's folder box by 156px; the list is now a grid whose rows take the table's tracks through subgrid, and the cards are auto-fill so they need no column count at all. .wiki-resources is the query container, so narrowing the folder with the window left wide is what drops columns, which the old viewport media query could not express. Measured: list content 502px to 344px at a 360px window, cards 167px to 282px in a 576px folder, desktop unchanged.

II. Editor preview and assist

To-do items 19 and 20. The renderer in both is the existing WikiParser, so neither adds a client-side parser.

  1. A. Live preview pane. Split the editor into textarea and preview, refreshed as the source is typed by rendering it through WikiParser.

    The rendering happens in the browser through the port of WikiParser already in help.js, so a keystroke costs no request; it waits for a pause in typing, and nothing is rendered at all until the book beside the way into the page's history opens the pane, which starts closed and leaves the source the whole width until it does; the book is shut while the preview is out of sight and open while it is being read. Source and preview share one window of a settled height, split by the existing split-adjuster from wiki.js, and the editor's row of controls moves above both columns as the preview opens so the two start at the same place and end level; either can be given more room by dragging, each is put at the other's place in the document as it is scrolled. Under 52rem the pair becomes one column with the preview above the source, and a markdown group gets no pane since only the mediawiki engine is ported. Picking out words in either pane shows them in the other: the two are matched on their letters and digits alone, since markup is in one and not the other, and the answer is carried back to where those letters sit in each. A resource written out as its own data draws in the preview, since everything needed is in the markup.

  2. B. Markup-insertion toolbar. Buttons that insert wiki markup at the cursor, with the textarea still the source of truth and the preview from II.A showing the result. Comes after II.A. Proposed.

III. Category templates

  1. A. Flesh out the category mechanism. Grow {{category|...}} and {{category-list|...}} into a real content tool: named parameters, style presets, pagination, auto-stub index pages, a "Filed under" affordance, cross-group lookup, and semantic HTML in place of the custom tags. To-do item 18. Proposed.

IV. Asynchronous secret-ballot witnesses

  1. A. Email-based witness flow. Complete the asynchronous flow for {{secret-ballot|w1|w2|...}} so named witnesses take part by email rather than having to be present at once. To-do item 21. Proposed.

V. Document the resource-folder UI

  1. A. Write up the wiki resource-folder UI. Document the folder interface and its actions as wiki help source delivered as a .txt, not edited into the autogenerated help pages. Best done after I. To-do item 37. Proposed.

Order and dependencies

I, II, III and IV are independent. Within II the preview comes before the toolbar; V waits on I so it describes finished behavior.

Other work accomplished

Oldest first.

  1. Git clone and pull over HTTP/2 failed. The Phase 6 smart-HTTP serving built the whole packfile synchronously on the event loop, so the server answered nothing while it ran and the client's connection went unserviced.

    Git requests now run on the cooperative fiber path and the build suspends periodically. The earlier attempt to pace the send with stream() was aimed at the wrong layer and, by dropping webExit, let the group controller render onto the reply; it was undone here.

  2. Pull failed with "bad line length character: PACK". A client with a long history negotiates in rounds, sending haves without saying "done" and expecting only an acknowledgment; the server answered with the packfile.

    A round with no depth and no "done" now returns just the acknowledgment, and skips the history walk since that round asks for no objects. A five-commit client sends every have and "done" at once, which is why the trouble looked size-dependent.

  3. Negotiation never settled and packfiles were held in memory. The server never named a commit the two sides shared, so a client gave up after a couple of hundred offers and asked for the whole history, which was then built as one string.

    The server now offers multi_ack_detailed, naming recognized commits as common and closing the round with NAK; a flush there is read as a missing acknowledgment and no closing line leaves the client waiting. The reply is written to a stream with a running checksum. A seven-hundred-commit pull settles in two rounds, and 17.7MB is built with 4.0MB of memory against 21.7MB as a string.

  4. A deferred reply cannot also stream. completeDeferredResponse sends only the buffered body, so a git request on the fiber path sent headers claiming eighty megabytes and then nothing.

    GitUploadPack now yields its reply piece by piece and serveUploadPack hands that to the ordinary streaming path. The fiber breathing and its interval constant went with it.

  5. An HTTP/2 reply must state its length. Sending with no stated length gave content-length: 0 and an empty body, since the length is taken from the buffered body, which a streamed reply leaves empty.

    The yielded pieces are written to a temporary file, its size is the stated length, and the file is streamed back and removed. Building before sending means a very large clone occupies the server while its packfile is made.

  6. Every version of every file was sent. The have lines stopped the commit walk but nothing was set against what the client holds, so a one-commit update packed a copy of the whole working tree.

    The walk now gathers everything the client's known commits record and skips it. A client one commit behind was sent 63 objects and 17.6MB and is now sent 3 objects and 1,755 bytes.

  7. Objects were sent whole. A new GitDelta writes and reads Git's delta format, working on bytes so binary content is handled too.

    Utility.php's diff and computeLCS were considered and not used: they compare lines and build a table capped at a quarter of a million cells. GitPackReader's private applier moved into GitDelta, leaving one implementation. A clone of a forty-commit history of a large file fell from 898,559 to 105,915 bytes.

  8. Differences could only be built against objects in the pack. An update to a file whose earlier version the client already held was still sent whole.

    The server now offers thin-pack and remembers the version of each file the client holds, sending a new version as its difference from that. A one-line edit fell from 22,508 bytes to 331.

  9. A deep clone of the live repository stopped partway. The reply stated 34,671,195 bytes and the connection closed with a differing amount delivered each time, over HTTP/1.1 as well as HTTP/2.

    Not the link: the same server sent a 142MB file from the repository to the same machine complete, so what differs is how the two are sent, and the packfile now goes out through readfile as that file does. The earlier change to the idle sweep, which treats a connection with a reply still to send as active, stays, though it was never tied to this. H3Listener does not share that defect, its idle check counting outbound packets.

  10. Say where the server's memory is going. The web server sat at 1924MB of a 2000MB limit for minutes, so the search that finally failed asked for 1.25MB and had nowhere to put it.

    The memory line now also reports about how many bytes the kept sessions hold, from a sample of twenty, and how many sit in connection buffers. Sessions are capped by count rather than weight, so their number alone did not say.

  11. The mail server ran out of memory during a read. It sat at about 120MB for an hour, then went to 445MB and to its 1GB limit within ten seconds, with the connection buffers empty at the last reading before it died.

    A connection's unread input was allowed 256MB on a 1GB limit, half of memory while the buffer grows, so it is now 32MB, one message plus its command. A stored message was read whole however large the file had become, so its size is checked first, on the storage every backend shares. The loop now records which IMAP command it is running, so the next crash line names it.

  12. A missing wiki page left nulls running through the media path. A warning, a deprecation and a second warning on one request were one defect: getPageInfoByName answers false, reading PAGE off it warns and yields null, and that null reached preg_match.

    All three now check that what came back is an array, in both places the media path does this, and the sign-in check treats a missing page as an empty one.

  13. Every page was wider than its own window between about 601 and 1060 pixels. min-width: 66rem on .body-container meant the content column could never be narrower than 1056px however narrow the window was, and the phone layout does not take over until 600px.

    That width and the floor under it are now held to the window as well as to their design, and seven more floors underneath doing the same thing give way to their container the same way. Measured on two pages at ten widths, the document's scroll width now equals the window at every width from 601 up, where it had been stuck at 828 and 933. It fed back into I.B, where the folder can now take widths that left one stretched card per row.

  14. Reporting an issue against a repository needed an account. The tracker is public to read, so a visitor who hits a bug either registers or says nothing, and letting anyone report must not also let anyone spam.
    1. a. Let a signed-out visitor submit a report, held rather than numbered, since an issue number is permanent and a spam run would spend a block of them. The reporter gives an unverified handle stored beside an opaque token derived from their address, so an editor can recognize a repeat reporter without seeing one.

      A submission passes a backoff counted apart from the sign-in forms, a decoy field, a timing check, stateless proof of work and a rolling daily allowance, and is told in six words or fewer what became of it. Testing found the proof of work replaced the string an answer was answering before reading the answer, and kept it in a session a cookieless visitor does not have, so it now takes string and time back from the form. A refusal names its reason except where naming it would tell a script what to fix.

    2. b. Add the editor queue as one more issue filter listing waiting reports, minting the number through the existing createGitIssue only on acceptance. An accepted issue shows the handle where the reporter column would otherwise show a username.

      Rows are selected the way rows are selected elsewhere and decided together, and rejecting runs through one control whose catch also shuts the reporter out for a week, a month or for good, with a searchable paged list of who is shut out and a control to let them back in. Both lists page through one query narrowing on page name and record text, so only the rows shown are opened, and each list is read by scrolling: reaching the end asks for the next page and the rows arrive into the table, the way a feed reads. A waiting report and a ban are each a page beside the repository, told apart from a numbered issue by the mark after its name, which needed a one-off rename of existing issue pages.

    3. c. Give the repository a setting — none, moderated or unmoderated — defaulting to moderated, so it can be on for one repository and off elsewhere.

      It is a field in the repository page's own settings rather than a group setting, which keeps the whole feature clear of any schema change. validateHashCode moved from RegisterController up to Controller so the account forms and this one ask the same question through one copy of it.

  15. A second column heading appeared part way down a scrolled issue list. The pager took every row out of an answer, so when an answer was a whole page rather than the rows alone, the page's own heading row came with it while the markup around it was dropped by the table parser.

    It now takes only rows of the list itself, and an answer carrying none of them ends the paging rather than being asked for again. Measured against a whole-page answer, the old reading gave 11 rows with a heading among them and the new one gives 10 rows with none. The rows are also read back inside a table rather than a detached table body, which is the one context where a browser is required to parse them as rows.

  16. An activity that is mostly working area was held to a reading width. The content column stopped well short of the browser window and the activity inside it was capped narrower still, which left the wiki editor, mail and messages using a fraction of a wide screen.

    The column now stands 1.2rem narrower than the window, through a named property, the margins either side of the centered container are halved to 1.5rem, and an activity whose width is working room rather than a line of prose carries a class that drops the cap: the wiki page whether written or read, mail, and messages. Measured at 1920 pixels the column went from 1322 to 1901 and the wiki page from 885 to 1853 in both modes, with nothing overflowing at 360, 601, 800 or 1060 pixels. Every length this arc added is said in rem and every direction as a logical property, which is what Phase 5 settled the stylesheet on.

  17. The mail server ran out of memory again, during an IMAP command. It climbed from 114MB to its 1GB limit in about two minutes with the connection buffers empty and no fibers parked, and the fatal named only "imap key=2259 UID", which says neither whose mail was being read nor whether the command was a fetch, a search or a store.

    The crash line now names the account, the folder and the word after UID, and the periodic memory line says how many folder indexes are cached and how many records they hold, so the next occurrence answers both questions itself. Rebuilding a folder's index no longer grows its snapshot as one string nor reads the whole index a second time as records; it streams both files a line at a time and takes only flags from the index. Measured on a folder of 77,000 messages that is 2.0MB held beside the records against 6.2MB, but the same measurement shows neither figure explains a climb of 319MB, so the cause is not yet established.

  18. The server's default settings were stated in two places and the constructor set only two of them. The full set was built inside listen, so anything reading a setting before listening began found nothing there, and the root index carried its own copy of the request-size default.

    The settings now live in defaultServerGlobals alone. The constructor starts a server with all of them rather than two, listen starts from the same call, and the root index asks for the one it wants. Checked against the source, all fifteen settings read anywhere in the class are among them, bar one read with a fallback of its own.

    Separately, the file cache remembers the hash of what was last read from or written to each path, and a write whose contents match that hash is skipped, so saving something unchanged costs no disk write.

  19. A session's contents were held in memory with nothing bounding their size. Ten thousand sessions were allowed whatever they each weighed, and the reported figure was an average of twenty scaled up, which would miss one large session among many small ones entirely.

    A session's contents now live in a file of their own, read back when a request needs them and written through the cache above, so a request that does not change a session costs no write at all. What the server holds for each is its time, its use-count and nothing else, which is all the sweep for stale sessions and the eviction of the least recently used ever needed; evicting one now takes its file with it. A session grown past what the server accepts as a request body is refused rather than written.

    The block in listen that restored sessions from a configuration array is gone: nothing produced what it read.

  20. A test run printed a line that was not a test result. Refusing to read an oversize stored message went to the error log, which under the command-line PHP is standard error, so the run said so in the middle of its results.

    A storage class now reports a problem through a handler that can be handed in, falling back to the error log when none is. The test that makes an oversize message catches it, so a run says only what the tests themselves say, and the saying of it is checked rather than merely heard. Measured on a filtered run, standard error is empty and every line on standard output is a test result or a heading.

  21. The cached folder indexes were bounded by how many folders were held, not by how much. One mailbox of a hundred thousand messages outweighs sixty small ones, and the rebuild that compaction calls for rewrote the whole index every time even when it produced what the file already held.

    The cache now has a ceiling on the message records held across it and drops the least recently used until they fit, and a folder too large to fit on its own is not held at all rather than emptying the cache for something that still would not. The snapshot a rebuild would write is hashed a line at a time and the write is skipped when the file already holds it. Measured on an index of 154,853 lines and 77,427 messages, the file is 3.2MB, one read costs 0.352 seconds and the records it makes cost 48MB.

    What the cache holds changed too: three lists of plain values beside each other, keyed by uid, rather than a small array for every message. A record holds four scalars and PHP spends more on the array around them than on the values inside, so the same folder costs 6.01MB where it cost 48MB, and building the records back out of it takes 0.035 seconds against 0.352 to parse the index file again. A first attempt at this packed the values into one binary string, which measured slower to decode than re-reading the file and was dropped; plain values keyed by uid need no unpacking at all. Counting live messages needs no records built, since the uids are the keys the compact form is already keyed by.